更新儲存在 Angular 訊號中的 Map 時可能會出現微妙的錯誤,這主要是由於 change detection 如何與物件引用 (object reference) 配合使用。將鍵 (key) 新增至 Map 時,對原始 Map 的引用 (reference 保持不變,因為該操作會就地修改現有物件,而不是建立新實例。因此,Angular 的 change detection 無法辨識訊號 (signal) 已更新,導致元件中的視圖 (view) 和計算訊號 (computed signals) 無法反映 Map 的最新狀態。
請容許我解釋一下這個問題並提供逐步的解決方案。
export type Data = {
name: string;
count: number;
}
function updateAndReturnMap(dataMap: Map<string, number>, { name, count }: Data) {
const newCount = (dataMap.get(name) || 0) + count;
if (newCount <= 0) {
dataMap.delete(name);
} else {
dataMap.set(name, newCount);
}
return dataMap;
}
當數值為非正數時,此函數會從 Map 中刪除鍵。否則,現有鍵將更新為新數值。此函數修改 Map 內容並返回以更新訊號。
// main.ts
const MY_MAP = new Map<string, number>();
MY_MAP.set('orange', 3);
@Component({
selector: 'app-root',
standalone: true,
imports: [AppSignalObjectComponent, AppSignalMapDataComponent],
template: `
<button (click)="addBanana()">Add banana</button>
<div>
<p>aMap and champ works in the current component because the equal function always returns false</p>
@for (entry of aMap(); track entry[0]) {
<p>{{ entry[0] }} - {{ entry[1] }}</p>
}
<p>Most Popular: {{ champ()?.[0] || '' }}</p>
</div>
`,
})
export class App {
aMap = signal<Map<string, number>>(new Map(MY_MAP));
champ = computed(() => {
let curr: [string, number] | undefined = undefined;
for (const entry of this.aMap()) {
if (!curr || curr[1] < entry[1]) {
curr = entry;
}
}
return curr;
});
addBanana() {
const data = { name: 'banana', count: 10 };
this.updateMaps(data);
}
updateMaps(data: Data) {
this.aMap.update((prev) => updateAndReturnMap(prev, data));
}
}
MY_MAP
是一個包含鍵(orange)且值為 3 的 Map。 我複製了 MY_MAP 並使用預設選項建立了一個名為 aMap 的訊號。 champ
計算訊號迭代 map 以尋找具有最高值的鍵。 HTML 範本有一個用於顯示地圖條目 (map entries) 的 @for
迴圈、一個用於顯示 champ
訊號值的段落 (paragraph) 元素以及一個用於向其中新增 [banana, 10] 的按鈕。 點擊按鈕後,會顯示新的地圖條目,但champ
值不正確。預設 equal 函數使用三重等於 (===) 來比較值;因此,函數會比較 Map 的引用。 然而,Map 的引用並沒有被修改;僅新增了一個新鍵。因此,應用程式不會重新計算 champ
計算訊號。
這是一個錯誤,但透過改變 equal
函數很容易解決。
aMap = signal<Map<string, number>>(new Map(MY_MAP), { equal: () => false });
我重寫 equal
函數以在訊號函數的選項中總是返回 false。 因此,Angular 認為訊號發生了變化;此元件已髒,需要在 change detection 期間更新。 champ
計算訊號取決於重新計算的 aMap
訊號。 範本顯示 aMap
和 champ
的值;因此,視圖也會重新渲染。有比這更好的解決方案,因為它可能導致不必要的 change detection 週期,但它修復了錯誤。
假設我重構了 App 元件,將 @for
迴圈和 champ
訊號邏輯移至新元件中以供重複使用。
// map-data.component.html
<div>
<p>{{ title }}</p>
@for (entry of mapData(); track entry[0]) {
<p>{{ entry[0] }} - {{ entry[1] }}</p>
}
<p>Most Popular: {{ mostPopular() }}</p>
</div>
// signal-map-data.component.ts
@Component({
selector: 'app-signal-map-data',
standalone: true,
templateUrl: `./map-data.component.html`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export default class AppSignalMapDataComponent {
mapData = input.required<Map<string, number>>();
title = inject(new HostAttributeToken('title'), { optional: true }) || 'Signal';
champ = computed(() => {
let curr: [string, number] | undefined = undefined;
for (const entry of this.mapData()) {
if (!curr || curr[1] < entry[1]) {
curr = entry;
}
}
return curr;
});
mostPopular = computed(() => this.champ()?.[0] || '');
}
接下來,我將 AppSignalMapDataComponent
元件匯入到 App
元件中,並使用它在 HTML 範本中顯示相同的資料。
// main.ts
function updateAndReturnMap(dataMap: Map<string, number>, { name, count }: Data) { ... same logic as before … }
const MY_MAP = new Map<string, number>();
MY_MAP.set('orange', 3);
@Component({
selector: 'app-root',
standalone: true,
imports: [AppSignalMapDataComponent],
template: `
<button (click)="addBanana()">Add banana</button>
<app-signal-map-data [mapData]="aMap()" title='It does not work because the map reference does not change.' />
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
aMap = signal<Map<string, number>>(new Map(MY_MAP), { equal: () => false });
addBanana() { … same logic … }
updateMaps(data: Data) {
this.aMap.update((prev) => updateAndReturnMap(prev, data));
}
}
我單擊按鈕將新鍵 (banana) 添加到 map。但是,AppSignalMapDataComponent
元件未顯示正確的結果。為什麼?
這是因為 mapData
輸入的引用保持不變。因此, change detection 不會更新 AppSignalMapDataComponent
元件中的視圖和計算訊號。
我沒有將 aMap
直接傳遞到訊號輸入 (signal input),而是製作 aMap
的副本並將新實例傳遞給它。
function updateAndReturnMap(dataMap: Map<string, number>, { name, count }: Data) { … same logic as before … }
const MY_MAP = new Map<string, number>();
MY_MAP.set('orange', 3);
@Component({
selector: 'app-root',
standalone: true,
imports: [AppSignalMapDataComponent],
template: `
<button (click)="addBanana()">Add banana</button>
<app-signal-map-data [mapData]="aDeepCopyMap()" title='Signal with a new map' />
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
aDeepCopyMap = signal<Map<string, number>>(new Map(MY_MAP));
addBanana() { … same logic… }
updateMaps(data: Data) {
this.aDeepCopyMap.update((prev) => updateAndReturnMap(new Map(prev), data));
}
}
aDeepCopyMap
是儲存 Map 的訊號。 updateMaps
方法呼叫 new Map(prev)
建立一個新 Map 並將其傳遞給 updateAndReturnMap
函數以新增鍵、banana 及其值。 aDeepCopyMap
的 update
方法以新的 Map 更新訊號。 訊號輸入接收新的引用並觸發 change detection 以更新元件的視圖和計算訊號。 AppSignalMapDataComponent
元件在 HTML 範本中顯示正確的結果。
這是一個合理的解決方案,因為呼叫 Map 建構函數 (constructor) 並不昂貴。最後一個解決方案是將 map 儲存在物件中,並在每次 map 操作後建立新的物件引用。
// signal-object.component.ts
@Component({
selector: 'app-signal-object',
standalone: true,
templateUrl: './map-data.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export default class AppSignalObjectComponent {
store = input.required<{ map: Map<string, number> }>();
mapData = computed(() => this.store().map);
champ = computed(() => {
let curr: [string, number] | undefined = undefined;
for (const entry of this.store().map) {
if (!curr || curr[1] < entry[1]) {
curr = entry;
}
}
return curr;
});
mostPopular = computed(() => this.champ()?.[0] || '');
title = 'Signal is an Object with a Map';
}
// main.ts
function updateAndReturnMap(dataMap: Map<string, number>, { name, count }: Data) { ... same logic as before ... }
const MY_MAP = new Map<string, number>();
MY_MAP.set('orange', 3);
@Component({
selector: 'app-root',
standalone: true,
imports: [AppSignalObjectComponent],
template: `
<button (click)="addBanana()">Add banana</button>
<app-signal-object [store]="this.store()" />
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
store = signal({ map: new Map(MY_MAP) });
addBanana() { ...same logic... }
updateMaps(data: Data) {
this.store.set({ map: updateAndReturnMap(this.store().map, data) });
}
}
store
是儲存包含 Map 的物件的訊號。 updateMaps
方法呼叫 updateAndReturnMap
函數將鍵、banana 和 value 加入到同一個 Map 物件中。 此方法建立一個新物件 {map: <a Map Object> }
,並呼叫 store
的 set
方法來覆寫訊號。訊號接收新的引用,並發生 change detection。 AppSignalObjectComponent
元件的視圖、訊號輸入和計算訊號因此而更新。
鐵人賽的第 37 天到此結束